
一套系統賣到多個地區,欄位標題、表單名稱、下拉選項的文字都要跟著語言換。這是做過國際化的人都處理過的題目,而 .NET 的標準答案是 .resx:字串放進資源檔,編譯成附屬組件,執行期以 CultureInfo.CurrentUICulture 解析。
bee-library 沒有走這條路。語系資源是 Day 4 那十三種定義檔裡的一種,跟 FormSchema、版面與選單放在同一個根目錄底下,以同一套機制載入、快取、疊加。昨天談的客製層裡,最常被用到的就是這一份。
本篇說明:
「這個欄位在我們公司叫別的名字」是 ERP 反覆會收到的需求。如果譯文編譯在組件裡,改一個字要走一次完整的建置與發佈,份量跟修一段程式一樣重。
這裡不是在說 .resx 不好。它的模型是把譯文當成組件的一部分,適用範圍很明確:跟著版本走、只在 .NET 行程內顯示、一個部署只有一套。工具鏈成熟,存取有強型別與編譯期保護。框架要的五件事剛好都落在這個範圍之外:
| 框架需要的 | 為什麼需要 |
|---|---|
| 譯文能單獨換掉,不重編、不換組件 | 改一個標題不該等於發一次版 |
| 語言與客製化代碼是每次呼叫的參數,不是行程的狀態 | 同一個行程同時服務不同語言、不同客製的請求 |
| 一份客製只覆蓋幾個 key,其餘沿用套裝的 | 昨天那一節的 key 級疊加 |
| 前後端共用同一份資源檔 | 譯文編進組件,前端就得自己再維護一份,兩份遲早會不一樣 |
| 譯的不只是字串,還有帶順序的選項集 | 下拉選項要 code 與 text 成對,順序也有意義 |
第一件是可以實測的。案例的伺服器跑著,改掉 {DefinePath}/Language/zh-TW/Order.Language.xml 裡某一個欄位標題的值,不重編、不重啟,下一次取那一份語系資源,拿到的就是新的那個字。快取以檔案本身當變更信號,這條路 Day 12 已經鋪好了,語系資源只是走在上面的另一種定義。
第二件從型別上就看得出來。負責解析的服務對使用者無狀態,語言與客製化代碼都是參數(節選自 ILanguageService.cs):
string GetLangText(string customizeId, string lang, string @namespace, string subKey);
.resx 那一側對應的位置是 CultureInfo.CurrentUICulture,一個掛在執行緒上的環境值。單一使用者的桌面應用裡它很方便;一個同時服務許多 session 的伺服器行程裡,每次呼叫都要先把它設對再設回去,而且沒有任何東西會提醒你漏了哪一次。
不走 .resx 不等於要放棄 .NET 的在地化介面。框架附了一個轉接,把標準的 IStringLocalizer<T> 接到語系定義檔上(節選自 BeeStringLocalizer.cs):
public sealed class BeeStringLocalizer<T> : IStringLocalizer<T>
型別參數的簡單名字就是 namespace,BeeStringLocalizer<Common> 讀的是 Common 那一份資源。
換掉儲存體的代價有兩筆:key 打錯字編譯器不會擋,.resx 那邊會,這裡要到執行期才看得見;GetAllStrings 回的是空集合,一份資源列不出來,因為底下那個服務只答得出單一個 key 的值。
檔案路徑是 {DefinePath}/Language/{lang}/{namespace}.Language.xml。一種語言配一個 namespace 就是一個檔。namespace 是自己切的分類,照表單、照模組、照功能都可以,怎麼好管理就怎麼分,跨表單共用的那一份習慣叫 Common。
內容分兩區,下面節選自框架自帶的 Employee.Language.xml:
<LanguageResource Namespace="Employee" Lang="zh-TW">
<Items>
<LanguageItem Key="Schema.DisplayName" Value="員工" />
<LanguageItem Key="Table.Employee.DisplayName" Value="員工" />
<LanguageItem Key="Field.sys_id.Caption" Value="員工編號" />
<LanguageItem Key="Field.ref_dept_name.Caption" Value="部門名稱" />
</Items>
<Enums />
</LanguageResource>
Items 是文字,以 sub-key 為鍵;Enums 是選項集。一次查找的完整 key 是 {namespace}.{subKey},取第一個句點切開,所以 sub-key 本身可以帶句點。
FormSchema 靠三個 sub-key 對上語系資源把一份 FormSchema 在地化的是 FormSchemaLocalizer,namespace 取表單自己的 ProgId,然後照固定慣例組出 sub-key。整個對應只有三條規則加一個例外:
| 要換掉的 | sub-key | 由誰決定 |
|---|---|---|
| 表單名稱 | Schema.DisplayName |
固定字串 |
| 資料表名稱 | Table.{TableName}.DisplayName |
FormSchema 裡的資料表名 |
| 欄位標題 | Field.{FieldName}.Caption |
FormSchema 裡的欄位名 |
| 下拉選項的文字 | 不走 sub-key,走 FormField.LangEnumName |
欄位上指名的選項集名稱 |
前三條的 key 全部從 FormSchema 推得出來,所以一份表單有哪些 key 要翻,不是靠人記,而是拿定義走一遍就列得出來。要產生翻譯樣板、要檢核哪些 key 還沒翻、要用工具批次補,都不必先讀懂任何程式碼。定義是結構化資料,Day 5 列過它的三個直接後果:批次腳本掃得動、工具產得出來、AI 接得上。這三件事在語系 key 上特別成立,因為它的形狀完全由另一份定義決定。
這裡還有一個連帶的分工,是 Day 7 留下來的。昨天那份客製版面片段上的英文標題也是同一件事:版面檔上可以寫標題,但執行期會被已經在地化的 FormSchema 覆蓋掉,覆蓋不到的才留版面自己的。版面檔只描述結構,標題的權威來源是 FormSchema,而 FormSchema 的標題來自語系資源。三層下來只有一條路徑會決定畫面上那個字。
LanguageEnum 是一組有順序、以 code 為鍵的項目:
<Enums>
<LanguageEnum Name="Gender">
<Entry Code="M" Text="男" />
<Entry Code="F" Text="女" />
</LanguageEnum>
</Enums>
Code 是存進資料庫的值,Text 是給人看的字,順序照它們在 XML 裡的先後保留,下拉選單與表格的 lookup 綁定不必再排一次。欄位要用哪一組,寫在 FormSchema 的 LangEnumName 上:OrderStatus 這種裸名字在自己的 namespace 裡找,Common.Gender 這種完整名稱就跨到別的 namespace 去拿。共用的選項集因此只需要維護一份。
命中的時候,框架把整組 Entry 換成欄位的 ListItems。欄位原本可以在 FormSchema 裡直接寫死選項,寫了 LangEnumName 就等於宣告「這一欄的選項改由語系資源供應」。
缺翻譯是常態,不是錯誤。一套系統上千張表單,永遠會有還沒翻到的地方,所以真正要設計的是「查不到的時候拿什麼頂上」。
框架的答案不只一個。同一個語系子系統裡有三個入口,三條路線的最後一段各不相同,其中一條還短一段:
| 入口 | 查找順序 | 最後一段 |
|---|---|---|
純文字 key(BO、IStringLocalizer) |
客製層與套裝層各查一次當前語言,再各查一次預設語言 | 回傳 {namespace}.{subKey} 這個 key 字串本身 |
| 表單標題(前面那三個 sub-key) | 客製層與套裝層各查一次當前語言 | 保留 FormSchema 裡原本寫的字面值 |
選項集(LangEnumName) |
客製層與套裝層各查一次當前語言,再各查一次預設語言 | 保留欄位原本的 ListItems |
純文字 key 那一條攤開來是四次查找加一個結尾,選項集走的是同樣四步、只有最後一段不同:
客製層(當前語言)─ 命中 → 回傳
↓ 未命中
套裝層(當前語言)─ 命中 → 回傳
↓ 未命中 ← 當前語言 = 預設語言時,以下兩步整段跳過
客製層(預設語言)─ 命中 → 回傳
↓ 未命中
套裝層(預設語言)─ 命中 → 回傳
↓ 未命中
回傳 "{namespace}.{subKey}"
前兩步與後兩步是同一段程式跑兩次,差別只在語言參數。客製層與套裝層之間怎麼挑,用的是昨天那個純決策元件,語系文字的粒度是 key 級,選項集是整份取代。
三條路線的差別不是疏漏,判準是這個位置手上還有沒有一個現成的、人看得懂的值。
FormSchema 的每一個欄位本來就帶著 Caption,那是寫定義的人自己打的字。缺翻譯的時候直接留著它,畫面上出現的是一個真的能讀的標題,沒有必要再退到另一種語言去撞運氣。選項集同理,欄位可能本來就有寫死的 ListItems。
純文字 key 什麼都沒有。Common.OK 這種 key 底下沒有任何備用文字,所以它多退一段到預設語言;連預設語言都查不到的時候,框架選擇把 key 原樣顯示在畫面上。這是刻意的:一個看得見的 Common.OK 會被回報,一個空字串或一個沉默的預設值不會。
實測的條件是當前語言 en-US、預設語言 zh-TW、只有 zh-TW 有譯文。同一份資料下,純文字 key 拿到 zh-TW 的譯文,表單標題與資料表名稱卻維持 FormSchema 裡的英文原字,沒有變成中文。同一個子系統裡兩種行為並存,而兩種都是對的。
當前語言就是 Day 13 那個 session 屬性,登入時從使用者資料表讀,讀不到退到部署層的預設設定。
預設語言是另一份設定,回答的問題也不同:一個是「這個 session 說哪種語言」,另一個是「這個 key 在它的語言裡查不到時往哪裡退」。兩者分開,連預設值都不一樣,前者是 zh-TW,後者是 en-US。所以同一個部署裡,使用者講中文、資源退回英文是合理的,因為套裝的資源本來就可能只有英文最完整。
昨天已經建立過前提:伺服端不先疊,兩層原始定義都送出去,用戶端拿到之後用同一個決策元件挑。語系資源走同一條路,System.GetLanguage 給套裝層,System.GetCustomizeLanguage 給客製層,而客製是哪一份,由伺服端看 session 決定。
用戶端因此要先知道這張表單會用到哪幾個 namespace。這件事 FormSchema 自己答得出來:表單的 ProgId 算一個,欄位上以完整名稱指名的 LangEnumName 各算一個。接著把這些 namespace 的兩層一次抓齊,會退到預設語言的話那一種語言也一起抓,收成一份同步的快照,在地化那一步就不必再跑伺服端(節選自 FormDefinitionLoader.cs)。
var customize = await _defineAccess.GetCustomizeLanguageAsync(language, ns);
var @base = await SafeGetLanguageAsync(language, ns);
snapshot[BuildKey(language, ns)] = new LanguageLayers(@base, customize);
代價是往返變多:組一張表單的定義要抓語系兩層、版面兩層。所以在 Avalonia 那一頭這件事是選配的,表單沒有指定載入器就不在地化、也不套客製,哪幾張表單要付這個成本由應用自己決定。
快取一格就是一種語言配一個 namespace,改動其中一格,其他語言與其他 namespace 都不受影響。這讓第二節那句「怎麼好管理就怎麼分」多一件事要想:一個語系檔就是一個快取單位。全部塞成一份,改一個字就整份重讀;切得太碎,用戶端組一張表單要多跑好幾趟。
這裡缺一個檔是正常答案,不是故障:讀取路徑不丟例外,「查過沒有」這件事本身也快取得起來。FormSchema 相反,少一個檔就是設定錯了。Day 4 分過的那兩類,語系資源落在寬鬆的那一邊。
兩端的語言也不是同一個來源:伺服端讀 session 上那個語言,桌面端預設讀行程的 UI 文化,要覆寫也可以。伺服端決定的是這一次呼叫回什麼訊息,前端決定的是使用者現在看到的畫面用哪種語言,本來就是兩件事。
案例的公司資料填了客製化代碼 northwind-demo,套裝與客製兩層各有一份訂單的語系檔。套裝那一份把訂單上的標題全部宣告了一遍,其中 Field.customer_rowid.Caption 是「客戶」、Field.ref_customer_name.Caption 是「客戶名稱」。客製那一份整份就這樣(Order.Language.xml):
<LanguageResource Namespace="Order" Lang="zh-TW">
<Items>
<LanguageItem Key="Field.customer_rowid.Caption" Value="經銷商" />
<LanguageItem Key="Field.ref_customer_name.Caption" Value="經銷商名稱" />
</Items>
<Enums />
</LanguageResource>
這家公司把「客戶」叫成「經銷商」,於是覆寫那兩個 key,其餘一個字都沒寫。兩個 key 裡,清單畫面上出現的是後面那一個:

實測兩支 API,套裝層回二十四個 key、客製層回兩個,兩份都原樣送到用戶端,挑哪一個是查找當下才發生的事。
案例的另一半在 en-US。那個語言連資料夾都沒有,兩層都回空字串,而畫面照樣是好的:三個 sub-key 一個都沒命中,標題就留在 FormSchema 自己寫的英文,走的正是第三節那條短一段的路線。訂單的狀態欄則直接在定義裡列了三個選項、沒有指名任何選項集,選項集那一條一次都沒有被走到。
這正是這套機制刻意保留的預設:沒有翻譯不是一種待修復的狀態,是一種合法的狀態。一個只賣一個地區、標題就用開發時打的那個字的部署,不必因為框架支援多語系就先產出一堆語系檔。等到真的要多一種語言,做的事情是往那個資料夾放一個檔案,FormSchema、版面與程式碼都不必動。
在地化落在定義檔,譯文從此不必跟著程式碼一起交付。改一個標題就是改一份資料,不必重編、不必換組件、不必重啟;代價是編譯器不再替你檢查 key,錯字要到執行期才看得見。
.resx 的模型接不住:譯文要能單獨換掉、語言與客製化代碼是每次呼叫的參數、一份客製只覆蓋幾個 key、前後端共用同一份資源檔、譯的不只是字串;IStringLocalizer 這個標準介面留著,換掉的只是底下的儲存體FormSchema 靠三個 sub-key 對上它,而那三個都是從定義本身算出來的;選項集不走 sub-key,走欄位上寫的那個名字,可以跨 namespace 共用最該帶走的是三條路線為什麼不一樣。長度不是由重要性決定,而是看這個位置手上還有沒有一個人看得懂的值:表單標題有現成的字面值可以留,所以不必再退;純文字 key 什麼都沒有,才多退一段到預設語言。
全部都查不到的那一段也是一個決定。框架回的是 key 字串本身,不是空字串、也不是某個看起來沒問題的預設值。缺翻譯沒有一個正確答案,只有要不要被看見,而看得見的那一種才會有人回報。
明天換一種情況:一家公司要的差異大到語系與版面都接不住的時候,業務 plugin 掛在哪裡,而客製化這條線目前又走到了哪裡。
本系列同步發表於 HackMD,完整目錄